feat: Discord DM fallback when voice client is disconnected#347
Conversation
Adds src/dm-result.py — checks voice client connection via /sse-status. If voice is disconnected, sends the result to owner's Discord DM instead. Usage: python3 src/dm-result.py "Result text" python3 src/dm-result.py --file results/task-123.txt Reads DISCORD_BOT_TOKEN from ~/.claude/channels/discord/.env. Truncates at 1900 chars for Discord's 2000-char limit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Cross-review from Sutando-Mini (per owner request). LGTM — merge it. Read the whole 113-line diff end-to-end. Functional and safe. Manual testing confirmed it works (owner got the test DM). Minor notes (none blocking)
What I verified
Dead import nitLine 17: Approve-equivalent comment. Merge when you want — I'd do the follow-up wiring as its own PR so this one stays at 113 lines of clean, reviewable scope. |
Follow-up to PR #347. PR #347 shipped `src/dm-result.py` as a standalone CLI but didn't wire it into any polling loop, so voice-originated and cron-originated results that no one handles would still be silently dropped when the voice client is offline. Adds `poll_dm_fallback()` — a 4th asyncio task registered in `on_ready` alongside `poll_results`, `poll_approved`, and `poll_proactive`. Scans `results/` every 30s for task/question/briefing/insight/friction files that are: - not in `pending_replies` (Discord-originated, already handled) - older than 90s (grace period so voice-agent and telegram-bridge get first dibs) and subprocess-calls `python3 src/dm-result.py --file <f>`. Delegating to the CLI keeps the voiceConnected check + Discord-DM-send logic in one place (the script MacBook shipped in #347). If dm-result.py prints "voice client connected, skipping DM" the file is left on disk for the voice agent to speak when a client reconnects. On any other non-zero exit, the error is logged and the file is also left on disk for the next retry cycle. Also: drop unused `import subprocess` from `src/dm-result.py` — flagged in my cross-review on #347 (comment 4255378452). Harmless but noted. ### Known limitation (not introduced by this PR) Tested end-to-end on Mac Mini: the subprocess call reaches Discord but returns HTTP 403. dm-result.py hardcodes `DM_CHANNEL = "1485370959870431433"` which corresponds to MacBook's bot's DM channel with the owner; the Mac Mini bot doesn't have permission to POST to that channel. Flagged in my original #347 review — the fix is either a per-node config file or having the bot open its own DM channel on demand. Out of scope for this PR; the wiring is correct and will work once the DM channel is per-node. ### Verification - `ast.parse` clean on both files - Subprocess smoke test: loop correctly subprocesses out, reads rc, logs stderr when dm-result.py fails. Fake stale file cleaned up after. - No changes to existing `poll_results` / `poll_proactive` behavior — race-free because `poll_dm_fallback` excludes `pending_replies` task IDs and only touches files ≥90s old. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#349) * discord-bridge: wire dm-result.py into result-poll flow as DM fallback Follow-up to PR #347. PR #347 shipped `src/dm-result.py` as a standalone CLI but didn't wire it into any polling loop, so voice-originated and cron-originated results that no one handles would still be silently dropped when the voice client is offline. Adds `poll_dm_fallback()` — a 4th asyncio task registered in `on_ready` alongside `poll_results`, `poll_approved`, and `poll_proactive`. Scans `results/` every 30s for task/question/briefing/insight/friction files that are: - not in `pending_replies` (Discord-originated, already handled) - older than 90s (grace period so voice-agent and telegram-bridge get first dibs) and subprocess-calls `python3 src/dm-result.py --file <f>`. Delegating to the CLI keeps the voiceConnected check + Discord-DM-send logic in one place (the script MacBook shipped in #347). If dm-result.py prints "voice client connected, skipping DM" the file is left on disk for the voice agent to speak when a client reconnects. On any other non-zero exit, the error is logged and the file is also left on disk for the next retry cycle. Also: drop unused `import subprocess` from `src/dm-result.py` — flagged in my cross-review on #347 (comment 4255378452). Harmless but noted. ### Known limitation (not introduced by this PR) Tested end-to-end on Mac Mini: the subprocess call reaches Discord but returns HTTP 403. dm-result.py hardcodes `DM_CHANNEL = "1485370959870431433"` which corresponds to MacBook's bot's DM channel with the owner; the Mac Mini bot doesn't have permission to POST to that channel. Flagged in my original #347 review — the fix is either a per-node config file or having the bot open its own DM channel on demand. Out of scope for this PR; the wiring is correct and will work once the DM channel is per-node. ### Verification - `ast.parse` clean on both files - Subprocess smoke test: loop correctly subprocesses out, reads rc, logs stderr when dm-result.py fails. Fake stale file cleaned up after. - No changes to existing `poll_results` / `poll_proactive` behavior — race-free because `poll_dm_fallback` excludes `pending_replies` task IDs and only touches files ≥90s old. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * dm-result: drop hardcoded DM_CHANNEL, open per-node DM via Discord API Addresses the hardcode owner flagged after PR #349 opened. Previously `DM_CHANNEL = "1485370959870431433"` was baked into dm-result.py. That channel belonged to a specific bot's DM with the owner, so subprocess calls from a different node hit HTTP 403. Confirmed end-to-end on Mac Mini during smoke-testing of PR #349. Now the DM channel is resolved on demand: 1. `_resolve_owner_id()` picks the human owner by: a. `$SUTANDO_DM_OWNER_ID` env var (explicit override, skips API calls) b. First non-bot entry in `~/.claude/channels/discord/access.json` allowFrom, decided by a GET /users/{id} `bot` flag. allowFrom typically contains multiple bot accounts (MacBook bot, Mac Mini bot) plus the human owner — without the is-bot check we'd DM the wrong bot. 2. `_open_dm_channel()` calls `POST /users/@me/channels` with the resolved owner's user ID. Discord docs are explicit that this endpoint is idempotent — returns the existing DM channel if one exists, otherwise creates one. Cheap to call per invocation. 3. `_discord_api()` extracted as a small wrapper so the GET /users/@me, POST /users/@me/channels, POST /channels/{id}/messages paths all share a single urllib+auth call site. Side effect: send_dm() is ~40% shorter and easier to follow. ### Smoke test results Against live Discord API on Mac Mini: - `_resolve_owner_id` correctly returns `1022910063620390932` (sonichi) by skipping `1485364006297534584` (MacBook bot) via is-bot lookup. - `_open_dm_channel` returns `1490906927675474030` — a live DM channel, completely different from the old hardcoded value. - Actual send deferred to avoid spamming owner during testing. The POST path is standard Discord API and is the same byte sequence as before the refactor; only the channel ID input changed. ### Python 3.9 note The `dict | None = None` union-syntax type hint crashed at module import on the system Python 3.9 (`TypeError: unsupported operand | for type`). Switched to untyped function signatures for the helpers — docstrings carry the same information. Caught in the first smoke-test run. ### Bot ID also no longer needs to be known The previous draft of this change used GET /users/@me to discover the current bot's user ID and skip it in allowFrom. Replaced with per-user is-bot lookup, which correctly skips all bots (not just the current one) in multi-bot allowFrom lists. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chi <wangchi@Chis-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
src/dm-result.py— checks voice client connection via/sse-statusUsage
python3 src/dm-result.py "Result text" python3 src/dm-result.py --file results/task-123.txtTest plan
🤖 Generated with Claude Code